# -*- coding: utf-8; -*-

"""The data model that Siocwave works with."""

import httplib2
import RDFSClosure
import rdflib

from meta import __version__


RDF = rdflib.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
"""The RDF namespace."""

RDFS = rdflib.Namespace('http://www.w3.org/2000/01/rdf-schema#')
"""The RDF Schema namespace."""

DCTERMS = rdflib.Namespace('http://purl.org/dc/terms/')
"""The Dublin Core Metadata Terms namespace."""

DC = rdflib.Namespace('http://purl.org/dc/elements/1.1/')
"""The legacy Dublin Core namespace."""

SIOC = rdflib.Namespace('http://rdfs.org/sioc/ns#')
"""The SIOC Core Ontology namespace."""

FOAF = rdflib.Namespace('http://xmlns.com/foaf/0.1/')
"""The FOAF namespace."""

RSS_CONTENT = rdflib.Namespace('http://purl.org/rss/1.0/modules/content/')
"""The RSS 1.0 Content module namespace."""


class LoadError(Exception):
    """Represents an error when loading an external resource."""
    def __str__(self):
        return str(self.args[0])


class GraphMgr(object):
    
    """Encapsulates an RDF graph and manages additions to it."""
    
    def _dummy(self, s): pass
    
    g = None
    """The `rdflib.ConjunctiveGraph` that contains the data."""
    
    log = _dummy
    """A callable that will be invoked with a `unicode` to log messages."""
    
    cache = None
    """An `str` path to a directory to be used as HTTP cache, or None."""
    
    rdfs_auto_infer = False
    """A `bool` indicating if RDFS inference is to be performed after loads."""
    
    preload_list = []
    """URLs to preload when `preload` is called."""
    
    def __init__(self):
        """Create a new graph manager instance.
        
        If `cache` is not None, it must be a `unicode` path to a directory to
        be used as an HTTP cache.
        """
        self.g = rdflib.ConjunctiveGraph()
        self.loaded = set()
    
    def _real_url(self, url):
        return url.split(u'#')[0]
    
    def _fetch_rdf(self, url):
        """Fetch RDF data from `url` via HTTP.
        
        Fetch RDF data from `url`, which must be a `unicode` HTTP URL. Return a
        2-tuple (format, content) where format is a guess at the format of
        content, suitable for passing to `rdflib.ConjunctiveGraph.parse`. Raise
        `LoadError` when unable to fetch.
        """
        http = httplib2.Http(self.cache)
        headers = {
            'Accept': 'application/rdf+xml, text/rdf+n3, text/n3, ' +
                      'text/turtle;q=0.9, application/x-turtle;q=0.9, ' +
                      'application/xhtml+xml;q=0.5, text/html;q=0.3, ' +
                      '*/*;q=0.1',
            'User-Agent': 'Siocwave/%s httplib2' % __version__
        }
        try:
            resp, content = http.request(url, headers=headers)
            if resp.fromcache:
                self.log(u'Cache hit')
        except Exception, ex:
            self.log(u'Failed to fetch %s: %s' % (url, str(ex)))
            raise LoadError(ex)
        if (not resp['status'].startswith('2')) and (not resp.fromcache):
            # Anything other than a 2xx response is treated as an error,
            # unless the response comes from a cache
            msg = u'HTTP response status is %s %s' % \
                (resp.status, resp.reason)
            self.log(u'Failed to fetch %s: %s' % (url, msg))
            raise LoadError(msg)
        
        # Guess the format from the response Content-Type
        format = None
        content_type = resp['content-type'].split(';')[0].strip()
        if content_type == 'application/rdf+xml':
            format = 'xml'
        elif content_type == 'text/plain':
            format = 'nt'
        elif content_type in ('text/turtle', 'application/x-turtle',
                              'text/rdf+n3', 'text/n3'):
            format = 'n3'   # Turtle is a subset of N3
        elif content_type in ('application/xhtml+xml', 'text/html'):
            format = 'rdfa'
        return (format, content)
    
    def load(self, url):
        """Load data from `url`.
        
        Fetch data from `url`, which must be `unicode`, and load it into the
        graph. Raise `LoadError` if unable to fetch or parse.
        """
        url = self._real_url(url)
        if url in self.loaded:
            self.log(u'Not loading %s because it is already loaded' % url)
        else:
            self.log(u'Loading %s...' % url)
            if url.startswith(u'http:') or url.startswith('https:'):
                format, content = self._fetch_rdf(url)
                try:
                    self.g.parse(data=content, format=format, publicID=url)
                except Exception, ex:
                    self.log(u'Failed to parse %s: %s' % (url, str(ex)))
                    raise LoadError(ex)
            else:
                try:
                    self.g.parse(url)
                except Exception, ex:
                    self.log(u'Failed to parse %s: %s' % (url, str(ex)))
                    raise LoadError(ex)
            self.loaded.add(url)
            self.log(u'Loaded %s, the graph has %d triples now' %
                     (url, len(self.g)))
            if self.rdfs_auto_infer:
                self.infer()
    
    def preload(self):
        """Load all resources from `self.preload_list`."""
        for url in self.preload_list:
            try:
                self.load(url)
            except LoadError:
                pass
    
    def infer(self):
        """Perform RFDS inference (closure, adding triples to the graph)."""
        len1 = len(self.g)
        RDFSClosure.create_RDFSClosure(self.g, addAxiomaticTriples=False)
        len2 = len(self.g)
        self.log(u'Inference materialized %d new triples, the graph has '
                 u'%d triples now' % ((len2 - len1), len2))
    
    def is_loaded(self, url):
        """Return True if `url` has already been loaded into the graph."""
        return (self._real_url(url) in self.loaded)
    
    def clear(self):
        """Remove all data from the graph."""
        self.g = rdflib.ConjunctiveGraph()
        self.loaded = set()
        self.log(u'Graph cleared')


class Resource(object):
    
    """Represents a resource.
    
    Represents a resource to be described, and provides ways for locating and
    obtaining additional data about the resource.
    """
    
    graph_mgr = None
    """The `GraphMgr` that manages data about the resource."""
    
    ref = None
    """The `rdflib.URIRef` or `rdflib.BNode` that identifies the resource."""
    
    def __init__(self, graph_mgr, ref):
        """Create a new instance with ID `ref` and data from `graph_mgr`."""
        self.graph_mgr = graph_mgr
        self.ref = ref
    
    def __eq__(self, y):
        return (isinstance(y, Resource) and (self.ref == y.ref))
    
    def __ne__(self, y):
        return ((not isinstance(y, Resource)) or (self.ref != y.ref))
    
    def __repr__(self):
        return '<Resource %s>' % self.ref
    
    def _get_first_obj(self, predicates, default=None):
        """Get the first object of the specified `predicates`, or `default`.
        
        Return the first object of any predicate in the iterable `predicates`
        for this resource as the subject. If no predicate matches, return
        `default`.
        """
        result = None
        for pred in predicates:
            objs = list(self.graph_mgr.g.objects(self.ref, pred))
            if len(objs) > 0:
                result = objs[0]
                break
        if result is None:
            return default
        else:
            return result
    
    @property
    def label(self):
        """A label for the resource (may or may not be ``rdfs:label``)."""
        return self.graph_mgr.g.label(self.ref, default=u'(unknown resource)')
    
    @property
    def types(self):
        """RDF types the resource has.
        
        A tuple of `unicode` strings containing labels that identify all the
        known types of this resource. The labels may be unrelated to the
        ``rdfs:label`` objects (if any).
        """
        types = self.graph_mgr.g.objects(self.ref, RDF['type'])
        return tuple(unicode(t) for t in types)
    
    def list_more(self):
        """Determine possible sources for more information.
        
        Return a list of 2-tuples (URL, description) where URL is the URL of
        a resource to be fetched and description is a short human-readable
        description (`unicode`) of why that resource may be of interest.
        """
        sources = []
        # Try own URI first.
        if isinstance(self.ref, rdflib.URIRef) and \
           not self.graph_mgr.is_loaded(unicode(self.ref)):
            sources.append((unicode(self.ref),
                            u'“Follow your nose” to the resource URI'))
        # Try rdfs:seeAlso.
        for see_also in self.graph_mgr.g.objects(self.ref, RDFS['seeAlso']):
            if isinstance(see_also, rdflib.URIRef) and \
               not self.graph_mgr.is_loaded(unicode(see_also)):
                sources.append((unicode(see_also),
                               u'rdfs:seeAlso %s' % see_also))
        return sources
    
    def load_more(self, url=None):
        """Load more information about the resource from the net.
        
        If `url` is None, try all data sources that are currently known to be
        of interest. Otherwise, only fetch the specified `unicode` `url`. On
        load errors, continue to the next source.
        """
        if url is None:
            to_load = [u for (u, desc) in self.list_more()]
        else:
            to_load = [url]
        
        for u in to_load:
            try:
                self.graph_mgr.load(u)
            except LoadError:
                continue


class Container(Resource):
    """Represents a sioc:Container."""
    
    def __repr__(self):
        return '<Container %s>' % self.ref
    
    def list_children(self):
        """Return a list of all children of this container.
        
        Return a list of child containers and child items for this container.
        Sub-containers come first, then items. Only those items are listed
        which are not replies to other items within this container.
        """
        def is_item_toplevel(item_ref, container_ref):
            """Determine if item is top-level in container.
            
            Return True if the sioc:Item identified by `item_ref` is not a
            reply to any sioc:Item within the sioc:Container identified by
            `container_ref`. Otherwise, return
            False.
            """
            g = self.graph_mgr.g
            for i in g.subjects(SIOC['has_reply'], item_ref):
                if ((i, SIOC['has_container'], container_ref) in g) or \
                   ((container_ref, SIOC['container_of'], i) in g):
                    return False
            for i in g.objects(item_ref, SIOC['reply_of']):
                if ((i, SIOC['has_container'], container_ref) in g) or \
                   ((container_ref, SIOC['container_of'], i) in g):
                    return False
            return True
        
        mgr = self.graph_mgr
        
        subcontainers = []
        for ref in mgr.g.objects(self.ref, SIOC['parent_of']):
            subcontainers.append(Container(mgr, ref))
        for ref in mgr.g.subjects(SIOC['has_parent'], self.ref):
            child = Container(mgr, ref)
            if child not in subcontainers:
                subcontainers.append(child)
        
        items = []
        for ref in mgr.g.subjects(SIOC['has_container'], self.ref):
            if is_item_toplevel(ref, self.ref):
                items.append(Item(mgr, ref))
        for ref in mgr.g.objects(self.ref, SIOC['container_of']):
            if is_item_toplevel(ref, self.ref):
                child = Item(mgr, ref)
                if child not in items:
                    items.append(child)
        items.sort(key=lambda i: i.created, reverse=True)
        
        return subcontainers + items
    
    @property
    def label(self):
        """A label for the resource (may or may not be ``rdfs:label``)."""
        predicates = (RDFS['label'], SIOC['name'])
        return unicode(self._get_first_obj(predicates,
                                           default=u'(unknown container)'))


class Item(Resource):
    """Represents a sioc:Item."""
    
    def __repr__(self):
        return '<Item %s>' % self.ref
    
    @property
    def label(self):
        """A label for the resource (may or may not be ``rdfs:label``)."""
        predicates = (RDFS['label'], DCTERMS['title'], DC['title'],
                      SIOC['name'])
        return unicode(self._get_first_obj(predicates,
                                           default=u'(unknown item)'))
    
    @property
    def title(self):
        """Title of the item."""
        predicates = (DCTERMS['title'], DC['title'], SIOC['name'])
        return unicode(self._get_first_obj(predicates, default=u'(unknown)'))
    
    @property
    def creator(self):
        """Creator of the item as an `Agent` or a `unicode` (or None).
        
        Depending on the circumstances, return: an `Agent` corresponding to the
        foaf:Agent who created the item; a `unicode` describing the creator of
        the item (i.e. the name); or None.
        """
        
        # First we try foaf:maker, because it leads directly to a foaf:Agent.
        maker_ref = self._get_first_obj((FOAF['maker'],))
        if maker_ref is not None:
            return Agent(self.graph_mgr, maker_ref)
        
        # Now, see if there’s a sioc:has_creator.
        creator_ref = self._get_first_obj((SIOC['has_creator'],))
        if creator_ref is not None:
            # Now perhaps we can get a foaf:Agent via sioc:account_of.
            creator = Resource(self.graph_mgr, creator_ref)
            agent_ref = creator._get_first_obj((SIOC['account_of'],))
            if agent_ref is not None:
                return Agent(self.graph_mgr, agent_ref)
            else:
                # Okay, no foaf:Agent for us today.
                # But maybe this creator has a sioc:name at least?
                name = creator._get_first_obj((SIOC['name'],))
                if name is not None:
                    return unicode(name)
                # How about an rdfs:label?
                label = self.graph_mgr.g.label(creator.ref, default=None)
                if label is not None:
                    return unicode(label)
        
        # Now, dcterms:creator.
        creator_ref = self._get_first_obj((DCTERMS['creator'],))
        if creator_ref is not None:
            # Try getting a label for this resource.
            label = self.graph_mgr.g.label(creator_ref, default=None)
            if label is not None:
                return unicode(label)
        
        # Finally, all that's left is a dc:creator, which could also point to
        # a literal.
        creator_ref = self._get_first_obj((DC['creator'],))
        if isinstance(creator_ref, rdflib.Literal):
            return unicode(creator_ref)
        else:
            # Try getting a label for this resource.
            label = self.graph_mgr.g.label(creator_ref, default=None)
            if label is not None:
                return unicode(label)
        
        # Nothing found. We give up.
        return None
    
    def list_more(self):
        """Determine possible sources for more information.
        
        Return a list of 2-tuples (URL, description) where URL is the URL of
        a resource to be fetched and description is a short human-readable
        description (`unicode`) of why that resource may be of interest.
        """
        sources = Resource.list_more(self)
        
        # If there's a sioc:has_creator, try some URLs for its object so that
        # the creator property has more chance of working.
        creator = self._get_first_obj((SIOC['has_creator'],))
        if creator is not None:
            if isinstance(creator, rdflib.URIRef) and \
               (not self.graph_mgr.is_loaded(unicode(creator))):
                sources.append((unicode(creator),
                                u'“Follow your nose” to the creator URI'))
            for see_also in self.graph_mgr.g.objects(creator, RDFS['seeAlso']):
                if isinstance(see_also, rdflib.URIRef) and \
                   (not self.graph_mgr.is_loaded(unicode(see_also))):
                    sources.append((unicode(see_also),
                                    u'Creator rdfs:seeAlso %s' % see_also))
        
        return sources
    
    @property
    def created(self):
        """The date of the item's creation, or None if unknown.
        
        The date is currently just a `unicode` in whatever format it was
        specified in RDF.
        """
        date = self._get_first_obj((DCTERMS['created'],))
        if date is not None:
            return unicode(date)
    
    @property
    def plain_content(self):
        """The `unicode` plain text content of the item, or None if unknown."""
        content = self._get_first_obj((SIOC['content'],))
        if content is not None:
            return unicode(content)
    
    @property
    def encoded_content(self):
        """The `unicode` encoded (e.g. HTML) content of the item, or None."""
        content = self._get_first_obj((RSS_CONTENT['encoded'],))
        if content is not None:
            return unicode(content)
    
    def list_children(self):
        """Return a list of all direct child items of this item."""
        children = []
        for child_ref in self.graph_mgr.g.subjects(SIOC['reply_of'], self.ref):
            children.append(Item(self.graph_mgr, child_ref))
        for child_ref in self.graph_mgr.g.objects(self.ref, SIOC['has_reply']):
            child = Item(self.graph_mgr, child_ref)
            if child not in children:
                children.append(child)
        children.sort(key=lambda i: i.created)
        return children


class Agent(Resource):
    """Represents a foaf:Agent."""
    
    def __repr__(self):
        return '<Agent %s>' % self.ref
    
    @property
    def label(self):
        """A label for the agent (may or may not be ``rdfs:label``)."""
        predicates = (RDFS['label'], FOAF['name'], FOAF['nick'])
        return unicode(self._get_first_obj(predicates,
                                           default=u'(unknown user)'))


class SiocGraphMgr(GraphMgr):
    """A graph manager that contains SIOC data.
    
    This class extends `GraphMgr` by providing additional operations that make
    sense on graphs filled with SIOC container and item data.
    """
    
    def list_toplevel_containers(self):
        """Return a list of containers not contained within others."""
        def _is_toplevel(ref):
            if len(list(self.g.objects(ref, SIOC['has_parent']))) > 0:
                return False
            if len(list(self.g.subjects(SIOC['parent_of'], ref))) > 0:
                return False
            return True
        
        containers = \
            [Container(self, cont)
             for cont in self.g.subjects(RDF['type'], SIOC['Container'])
             if _is_toplevel(cont)]
        containers.sort(key=lambda c: c.label)
        return containers
    
    def list_toplevel_items(self):
        """Return a list of top-level SIOC items.
        
        Return a list of SIOC items (`Item` objects) that are neither replies
        to other items nor contained in any known SIOC container.
        """
        def _is_toplevel(ref):
            if len(list(self.g.objects(ref, SIOC['has_container']))) > 0:
                return False
            if len(list(self.g.subjects(SIOC['container_of'], ref))) > 0:
                return False
            if len(list(self.g.objects(ref, SIOC['reply_of']))) > 0:
                return False
            if len(list(self.g.subjects(SIOC['has_reply'], ref))) > 0:
                return False
            return True
        
        items = [Item(self, item)
                 for item in self.g.subjects(RDF['type'], SIOC['Item'])
                 if _is_toplevel(item)]
        items.sort(key=lambda i: i.created, reverse=True)
        return items
